今天我們要來完成 Todo List 的最後一個主要功能,也就是編輯功能。
這邊先來釐清需求:
預計的做法是點擊 Edit 按鈕之後,原先的 Todo 內容會切換為輸入框,而 Edit 按鈕會變成 Confirm,若輸入欄位為空,則不更新狀態。
在做編輯功能之前,我們要稍微修改一下 TodoList 的程式碼,因為我們會需要原本的 title,來當作編輯新標題的初始值,但是我們當初是以 <p> 來包裹 {todo.title},並作為 children 傳遞給 Todo,這樣 Todo 收到的 children 資料型別會是 Object,物件無法作為 <input> 的 value 值,因此我們需要刪除 TodoList 中的 <p>,這樣傳入型別就會變成 String:
<ul>
{todos.map((todo) => (
<li key={todo.id} className='list-none'>
<Todo
isFinished={todo.isFinished}
id={todo.id}
onDelete={onDeleteTodo}
>
{todo.title}
</Todo>
</li>
))}
</ul>
根據需求,我們需要一個狀態來改變按鈕的內容,以及它相應的功能搭配,由於這個狀態只會在 Todo.tsx 檔案中使用,因此這邊我們選擇使用 useState 來保存局部狀態,這樣可以有效管理按鈕狀態和切換邏輯:
const [isEditing, setIsEditing] = useState(false)
並加入切換狀態的函式:
const toggleEditHandler = () => {
setIsEditing(!isEditing)
}
修改前的按鈕結構如下:
<div className='flex gap-[16px]'>
<button>Edit</button>
<button
onClick={() => {
onDelete(id)
}}
>
Delete
</button>
</div>
將按鈕的文字改為條件式渲染,並為按鈕綁定點擊事件:
<div className='flex gap-[16px]'>
<button onClick={toggleEditHandler}>{isEditing ? 'Confirm' : 'Edit'}</button>
<button
onClick={() => {
onDelete(id)
}}
>
Delete
</button>
</div>
試著新增一筆 Todo,並點擊 Edit 按鈕,它應該要能夠自由切換。
新增管理標題的狀態:
const [newTitle, setNewTitle] = useState(children)
新增監聽 input 內容變化事件函式,ChangeEvent 為 React 所提供,為 onChange 事件的型別,由於我們會需要透過 event.target.value 取得值,因此我們必須告訴 TypeScript,這是一個 HTMLInputElement:
const changeTitleHandler = (event: ChangeEvent<HTMLInputElement>) => {
setNewTitle(event.target.value)
}
將 JSX 內容改為條件式渲染,並綁上事件函式:
{isEditing ? (
<input
type='text'
value={newTitle}
className='px-2 py-1'
onChange={changeTitleHandler}
/>
) : (
children
)}
這時候會出現報錯 Type 'ReactNode' is not assignable to type 'string | number | readonly string[] | undefined'.,因此我們要檢查型別,以更嚴謹的方式來編寫:
{isEditing ? (
<input
type='text'
value={typeof newTitle === 'string' ? newTitle : ''}
className='px-2 py-1'
onChange={changeTitleHandler}
/>
) : (
children
)}
編輯文字會需要 id 比對,以及新的文字內容作為更新,因此 payload 需要包含這兩項內容的型別:
type ActionType =
| { type: 'ADD_TODO'; payload: string }
| { type: 'DELETE_TODO'; payload: number }
| { type: 'EDIT_TODO_TITLE'; payload: { id: number; title: string } }
在 reducer 內加入相應的邏輯:
case 'EDIT_TODO_TITLE':
return {
...state,
todos: state.todos.map((todo) =>
todo.id === action.payload.id
? { ...todo, title: action.payload.title }
: todo
),
}
建立提交函式:
const submitNewTitle = () => {
if (typeof newTitle === 'string' && newTitle.trim().length === 0) {
setNewTitle(children)
setIsEditing(false)
return
}
if (typeof newTitle === 'string' && newTitle.trim().length > 0) {
dispatch({
type: 'EDIT_TODO_TITLE',
payload: { id, title: newTitle },
})
}
}
在非編輯模式下執行提交函式,透過 useEffect 監管 isEditing 狀態,是為了確保每次用戶從編輯模式退出時,狀態能夠正確地提交,而不會造成重複提交:
useEffect(() => {
if (!isEditing) {
submitNewTitle()
}
}, [isEditing])
我們接下來要實現 isFinished 的狀態切換。
首先,為 action 定義型別,我們只會需要 id,因此型別為 number:
type ActionType =
| { type: 'ADD_TODO'; payload: string }
| { type: 'DELETE_TODO'; payload: number }
| { type: 'EDIT_TODO_TITLE'; payload: { id: number; title: string } }
| { type: 'TOGGLE_TODO_ISFINISHED'; payload: number }
於 reducer 內加入更新邏輯:
case 'TOGGLE_TODO_ISFINISHED':
return {
...state,
todos: state.todos.map((todo) =>
todo.id === action.payload
? { ...todo, isFinished: !todo.isFinished }
: todo
),
}
使用 dispatch 來更新 isFinished 狀態:
const checkboxHandler = () => {
dispatch({
type: 'TOGGLE_TODO_ISFINISHED',
payload: id,
})
}
將 checkbox 綁定到 checkboxHandler:
<input type='checkbox' onChange={checkboxHandler} checked={isFinished} />
經過了這幾天的奮鬥,我們終於把 Todo List 的基本功能都完成了,也在這個實作過程中,認識了許多由 React 所提供的型別,在接下來的幾天,我們會再稍微對 Todo List 進行優化,並且探索 Todo List 沒有機會用到的 TypeScript 小技巧。